All files / Bandstand/src/app/api/proposals/[id] route.ts

100% Statements 99/99
93.75% Branches 15/16
100% Functions 0/0
100% Lines 99/99

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 1111x   1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 1x 5x 5x 5x 5x 4x 4x 4x 5x 3x 3x 3x 3x 3x 3x 3x 3x     3x 3x 2x 2x 1x 1x 1x 1x 1x 1x 1x 3x 3x 5x 1x   1x 1x 2x 2x 2x 2x 2x 2x   1x   1x 1x 1x   1x 1x   1x 2x 1x  
export const dynamic = 'force-dynamic'
 
import {prisma} from '@/lib/db'
import {requireAdmin} from '@/lib/guard'
import {z} from 'zod'
import {isHttpUrl} from '@/lib/url'
import {route} from '@/lib/route'
import {addSongFromProposal, logActivity} from '@/lib/songs'
 
const PatchBody = z.object({
  title: z.string().trim().min(1).optional(),
  artist: z.string().trim().min(1).optional(),
  status: z.enum(['PENDING', 'APPROVED', 'ARCHIVED']).optional(),
  chartUrl: z
    .string()
    .trim()
    .refine(isHttpUrl, 'Must be http(s) URL')
    .nullable()
    .optional(),
  lyricsUrl: z
    .string()
    .trim()
    .refine(isHttpUrl, 'Must be http(s) URL')
    .nullable()
    .optional(),
  youtubeUrl: z
    .string()
    .trim()
    .refine(isHttpUrl, 'Must be http(s) URL')
    .nullable()
    .optional(),
})
 
export const GET = route(
  async (_req: Request, {params}: {params: {id: string}}) => {
    const {band} = await requireAdmin()
    const p = await prisma.proposal.findFirst({
      where: {id: params.id, bandId: band.id},
      select: {
        id: true,
        title: true,
        artist: true,
        chartUrl: true,
        lyricsUrl: true,
        youtubeUrl: true,
        status: true,
      },
    })
    if (!p) return new Response('Not Found', {status: 404})
    return Response.json(p)
  },
)
 
export const PATCH = route(
  async (req: Request, {params}: {params: {id: string}}) => {
    const {user: admin, band} = await requireAdmin()
    const json = await req.json()
    const parsed = PatchBody.safeParse(json)
    if (!parsed.success) return new Response('Bad Request', {status: 400})
    const before = await prisma.proposal.findFirst({
      where: {id: params.id, bandId: band.id},
    })
    if (!before) return new Response('Not Found', {status: 404})
    await prisma.$transaction(async (tx) => {
      const p = await tx.proposal.update({
        where: {id: params.id},
        data: parsed.data,
      })
      await tx.auditLog.create({
        data: {userId: admin.id, action: 'ADMIN_EDIT', targetId: params.id},
      })
      // An admin approving directly does what reaching the vote threshold
      // does: the song joins the book to learn
      if (p.status === 'APPROVED' && before.status !== 'APPROVED')
        await addSongFromProposal(tx, p, admin.id, band)
      else if (p.status === 'ARCHIVED' && before.status !== 'ARCHIVED')
        await logActivity(tx, {
          bandId: band.id,
          userId: admin.id,
          action: 'proposal.archive',
          targetType: 'proposal',
          targetId: p.id,
          summary: `archived the proposal ${p.title}`,
        })
    })
    return new Response(null, {status: 204})
  },
)
 
export const DELETE = route(
  async (_req: Request, {params}: {params: {id: string}}) => {
    const {user: admin, band} = await requireAdmin()
    const p = await prisma.proposal.findFirst({
      where: {id: params.id, bandId: band.id},
      select: {id: true},
    })
    if (!p) return new Response('Not Found', {status: 404})
 
    await prisma.$transaction(async (tx) => {
      // record audit
      await tx.auditLog.create({
        data: {userId: admin.id, action: 'ADMIN_DELETE', targetId: params.id},
      })
      // Cascade deletes votes via schema
      await tx.proposal.delete({where: {id: params.id}})
    })
 
    return new Response(null, {status: 204})
  },
)